Day18 到 Day20 做可觀測性的時候,我一直把 Pi 的 session 檔當成「對話存檔」——一份跑完之後可以打開來看的記錄。
直到我寫了一個小工具,把事件重新 fold 成狀態,然後看到這一行:
messages 1
一則。Day22 那次「強制觸發 compaction」的執行,跑了 8 輪、25 次工具呼叫,重建出來的最終狀態只剩一則訊息。
那個「1」最後讓我發現:那個實驗量錯了,而且我的量測程式漏算了一整筆錢。
今天講這個工具,以及為什麼「可重播的記錄」是 harness 最被低估的一個設計。
先看它長什麼樣(一行一筆,這裡只留欄位):
{"type": "session", "id": "01a0e6f2", "version": 3, "cwd": "..."}
{"type": "model_change", "id": "9777f26a", "parentId": null, "modelId": "gpt-5.6-luna"}
{"type": "thinking_level_change", "id": "0236e3f8", "parentId": "9777f26a", "thinkingLevel": "medium"}
{"type": "message", "id": "a10b64b1", "parentId": "0236e3f8", "message": {"role": "user", ...}}
{"type": "message", "id": "e82e3811", "parentId": "a10b64b1", "message": {"role": "assistant", "usage": {...}}}
{"type": "message", "id": "bbed593e", "parentId": "e82e3811", "message": {"role": "toolResult", ...}}
三個關鍵性質:
id 和 parentId。 這不是一條線,是一棵樹——重跑某一輪、編輯某則訊息,是長出一個兄弟節點,不是覆蓋掉原本的。第三點是整件事的核心:狀態不是被儲存的,是被算出來的。
def reduce_events(events):
state = State()
for event in events:
kind = event.get("type")
if kind == "model_change":
state.model, state.provider = event.get("modelId"), event.get("provider")
elif kind == "thinking_level_change":
state.thinking = event.get("thinkingLevel")
elif kind == "compaction":
# 摘要取代「下一次請求」要送的歷史;記錄檔裡兩者都留著
state.compactions.append(event["id"])
state.messages = [summary_message(event)]
elif kind == "message":
state.messages.append(event["message"])
accumulate_usage(state, event["message"].get("usage"))
return state
配一個挑分支的函式,就能重建任何一個時間點的狀態:
def chain_to(events, event_id=None):
"""走 parent 鏈,從這棵樹裡挑出一條分支。"""
by_id = {e["id"]: e for e in events if "id" in e}
node = by_id.get(event_id) if event_id else events[-1]
branch = []
while node is not None:
branch.append(node)
node = by_id.get(node.get("parentId"))
return list(reversed(branch))
就這樣。python -m analysis.session_replay <session.jsonl>:
session format v3
events in file 24
events on branch 23
events off branch 0
model openai-codex/gpt-5.6-luna
thinking medium
messages 21
turns 7
tool calls 13
compactions 0
input tokens 20070
cache read tokens 20480
output tokens 1282
cost usd 0.005962
第一件該做的事,是拿 replay 算出來的數字去對量測台當初記下來的數字:
python -m analysis.session_replay <session> --check <run 記錄>
# cross-check vs recorded metrics: {"cost_usd": 0.0, "tool_calls": 0}
差 0。這聽起來很無聊,但它的意思是:我這 30 天貼出來的每一個成本數字,都可以被任何人用這個檔案重新算一次。 那些數字不是我手貼的,是可驗證的。
--at <事件 id> 可以重建任意一點的狀態。拿 Day22 那次壓縮來看:
| 重建到哪 | 訊息數 | 輪數 | 工具呼叫 |
|---|---|---|---|
| 壓縮之前那一刻 | 34 | 8 | 25 |
| 壓縮之後(檔案最後) | 1 | 0 | 0 |
壓縮把 34 則訊息換成 1 則摘要——但檔案還是 38 筆事件,一筆都沒有少。
這是 Pi 這個格式做對的地方:壓縮不是「刪掉舊訊息」,而是追加一筆記錄,宣告從這裡開始換一條路走。原始歷史留在檔案裡,事後要稽核「摘要有沒有漏掉重要的東西」,資料都還在。
而正是這個「1」讓我去看事件順序,然後發現壓縮事件永遠是檔案的最後一筆——也就是 Day22 那個「摘要根本沒被用到」的結論。
發現問題之後,真正的麻煩才開始:
我的 metrics.py 漏算了摘要那一次模型呼叫的費用。 它只走 assistant 訊息加總 usage,但壓縮的 usage 掛在 compaction 記錄上。
以前這種事只有一個解法:重跑實驗。160 次執行、大約一小時、加上一筆模型費用,而且重跑出來的數字跟原本那批不能混著比。
但記錄都還在,所以改成這樣:
python -m bench.runner.recompute # 只報告差異
python -m bench.runner.recompute --write # 把新的 metrics 寫回 runs.jsonl
輸出:
e22_compaction: 5 run(s) changed
t5_migration__forced_compaction__r01 compaction_cost_usd: None -> 0.001598,
cost_usd: 0.006493 -> 0.008091, input_tokens: 19956 -> 24908, total_tokens: 50235 -> 55693
... (另外四次)
11 run(s) differ from the recorded metrics.
剩下的 150 次一位數都沒變——這同時證明了修正沒有波及其他實驗。
代價對比很誇張:
| 重跑 | 重算 | |
|---|---|---|
| 時間 | 約 1 小時 | 0.28 秒 |
| 成本 | 約 $1.0 | $0 |
| 跟舊資料可比嗎 | 不行(不同時間、不同模型狀態) | 就是同一批資料 |
Day22 那個從 +22%(區間含 0)變成 +45.7%(有把握)的修正,就是這 0.28 秒算出來的。
把「花了多少錢」這種衍生資料,從記錄裡算出來,不要在記錄的時候就算死。
我當初的量測台把成本算好、存進 runs.jsonl 就算了。如果我當初沒有順手把整份 session 也存下來,今天這個 bug 的修法就只剩重跑。
這 161 份 session、5,848 筆事件,總共只有 9.5 MB。用 9.5 MB 換「所有結論都可以重新推導」,是這整個專案 CP 值最高的一個決定。
順帶一提,這也是 Day26 那個閘門能修好的原因:v2 的測資,是從 session 裡把 agent 真正下過的指令抄出來的。 記錄不只能重算數字,還能變成測試資料。
這是稽核用的 replay,不是執行用的 replay。
它重建的是「狀態」,不是「結果」。模型呼叫不會重跑,所以:
後者要的是重新執行,而那就回到 Day7 的量測台:同樣的任務、同樣的條件、再跑一次。兩種 replay 解決的是不同問題,別混為一談。
另外,我這 161 個 session 全部都是直線——沒有一個出現分支。因為非互動 -p 模式跑完就結束,不會有人回頭改某一輪。parentId 的分支能力在我的資料裡沒有被用到,我只能說格式支援它(--resume、fork 都靠它),不能說我驗證過。
從這次的踩坑,我會給三個具體建議:
程式碼:analysis/session_replay.py、bench/runner/recompute.py。
最後一天。Day30 把 30 天量到的東西整理成一份可以直接拿去用的清單,公布完整的資料集,並且老實列出這個系列沒能回答的問題。